--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 65f1b2cc77b1a36bc3222635cb7779720820185b
Parents : 37b3f26
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-18T15:08:33-05:00
feat: implement non-exported CallNotificationTrampolineActivity for secure call handling and update AndroidNotificationBridge with trusted call action management
Changes
7 files changed, 211 insertions(+), 39 deletions(-)
Diff
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 0a694773..f29db13c 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -90,6 +90,18 @@
android:resource="@xml/device_filter" />
</activity>
+ <!--
+ Non-exported: notification answer/decline PendingIntents only.
+ Keeps forgeable CALL_ANSWER / CALL_DECLINE off the exported MainActivity.
+ -->
+ <activity
+ android:name=".CallNotificationTrampolineActivity"
+ android:exported="false"
+ android:excludeFromRecents="true"
+ android:noHistory="true"
+ android:taskAffinity=""
+ android:theme="@android:style/Theme.Translucent.NoTitleBar" />
+
<service
android:name=".MeshChatForegroundService"
android:exported="false"
diff --git a/android/app/src/main/java/com/meshchatx/AndroidNotificationBridge.java b/android/app/src/main/java/com/meshchatx/AndroidNotificationBridge.java
index 75255445..3e1e96bd 100644
--- a/android/app/src/main/java/com/meshchatx/AndroidNotificationBridge.java
+++ b/android/app/src/main/java/com/meshchatx/AndroidNotificationBridge.java
@@ -21,20 +21,80 @@ public final class AndroidNotificationBridge {
public static final String ACTION_CALL_DECLINE = "com.meshchatx.action.CALL_DECLINE";
public static final String ACTION_CALL_OPEN = "com.meshchatx.action.CALL_OPEN";
+ /** Values stored for {@link #offerTrustedCallAction(String)} / {@link #takeTrustedCallAction()}. */
+ public static final String TRUSTED_CALL_ACTION_ANSWER = "answer";
+ public static final String TRUSTED_CALL_ACTION_DECLINE = "decline";
+ public static final String TRUSTED_CALL_ACTION_OPEN = "open";
+
private static final int REQ_CALL_OPEN = 0x4c31;
private static final int REQ_CALL_FULL = 0x4c32;
private static final int REQ_CALL_ANSWER = 0x4c33;
private static final int REQ_CALL_DECLINE = 0x4c34;
private static final Object CONTEXT_LOCK = new Object();
+ private static final Object TRUSTED_CALL_LOCK = new Object();
private static volatile boolean doNotDisturbEnabled = false;
private static volatile String openConversationHashesCsv = "";
+ private static volatile String pendingTrustedCallAction = null;
private static final java.util.Set<Integer> postedMessageNotificationIds =
java.util.Collections.synchronizedSet(new java.util.HashSet<>());
private AndroidNotificationBridge() {
}
+ /**
+ * Map a notification intent action to a trusted call-control token, or null if unknown.
+ */
+ @Nullable
+ public static String mapCallNotificationAction(@Nullable String action) {
+ if (ACTION_CALL_ANSWER.equals(action)) {
+ return TRUSTED_CALL_ACTION_ANSWER;
+ }
+ if (ACTION_CALL_DECLINE.equals(action)) {
+ return TRUSTED_CALL_ACTION_DECLINE;
+ }
+ if (ACTION_CALL_OPEN.equals(action)) {
+ return TRUSTED_CALL_ACTION_OPEN;
+ }
+ return null;
+ }
+
+ /**
+ * Queue a call action produced only by the non-exported notification trampoline.
+ */
+ public static void offerTrustedCallAction(@Nullable String action) {
+ if (action == null) {
+ return;
+ }
+ if (!TRUSTED_CALL_ACTION_ANSWER.equals(action)
+ && !TRUSTED_CALL_ACTION_DECLINE.equals(action)
+ && !TRUSTED_CALL_ACTION_OPEN.equals(action)) {
+ return;
+ }
+ synchronized (TRUSTED_CALL_LOCK) {
+ pendingTrustedCallAction = action;
+ }
+ }
+
+ /**
+ * Consume the queued trusted call action (one-shot).
+ */
+ @Nullable
+ public static String takeTrustedCallAction() {
+ synchronized (TRUSTED_CALL_LOCK) {
+ String action = pendingTrustedCallAction;
+ pendingTrustedCallAction = null;
+ return action;
+ }
+ }
+
+ /** Test helper: clear any queued trusted action. */
+ public static void clearTrustedCallActionForTests() {
+ synchronized (TRUSTED_CALL_LOCK) {
+ pendingTrustedCallAction = null;
+ }
+ }
+
public static void setDoNotDisturbEnabled(boolean enabled) {
doNotDisturbEnabled = enabled;
}
@@ -194,42 +254,33 @@ public final class AndroidNotificationBridge {
}
private static Intent callIntent(Context ctx, String action) {
- Intent i = new Intent(ctx, MainActivity.class);
+ // Answer/decline must hit the non-exported trampoline. Content open may
+ // use MainActivity (UI only), but still prefer the trampoline so one path
+ // owns notification call intents.
+ Intent i = new Intent(ctx, CallNotificationTrampolineActivity.class);
i.setAction(action);
- i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
return i;
}
+ private static PendingIntent callPendingIntent(Context ctx, int requestCode, String action) {
+ return PendingIntent.getActivity(
+ ctx,
+ requestCode,
+ callIntent(ctx, action),
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
+ );
+ }
+
private static void postIncomingCall(Context ctx, String callerName, @Nullable String dedupeHex) {
NotificationManager nm = ctx.getSystemService(NotificationManager.class);
if (nm == null) {
return;
}
- PendingIntent open = PendingIntent.getActivity(
- ctx,
- REQ_CALL_OPEN,
- callIntent(ctx, ACTION_CALL_OPEN),
- PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
- );
- PendingIntent full = PendingIntent.getActivity(
- ctx,
- REQ_CALL_FULL,
- callIntent(ctx, ACTION_CALL_OPEN),
- PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
- );
- PendingIntent answer = PendingIntent.getActivity(
- ctx,
- REQ_CALL_ANSWER,
- callIntent(ctx, ACTION_CALL_ANSWER),
- PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
- );
- PendingIntent decline = PendingIntent.getActivity(
- ctx,
- REQ_CALL_DECLINE,
- callIntent(ctx, ACTION_CALL_DECLINE),
- PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
- );
+ PendingIntent open = callPendingIntent(ctx, REQ_CALL_OPEN, ACTION_CALL_OPEN);
+ PendingIntent full = callPendingIntent(ctx, REQ_CALL_FULL, ACTION_CALL_OPEN);
+ PendingIntent answer = callPendingIntent(ctx, REQ_CALL_ANSWER, ACTION_CALL_ANSWER);
+ PendingIntent decline = callPendingIntent(ctx, REQ_CALL_DECLINE, ACTION_CALL_DECLINE);
Person person = new Person.Builder().setName(callerName).setImportant(true).build();
String incomingLabel = ctx.getString(R.string.notification_incoming_call_label, callerName);
@@ -297,9 +348,8 @@ public final class AndroidNotificationBridge {
return;
}
- Intent open = new Intent(ctx, MainActivity.class);
+ Intent open = new Intent(ctx, CallNotificationTrampolineActivity.class);
open.setAction(ACTION_CALL_OPEN);
- open.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent pi = PendingIntent.getActivity(
ctx,
REQ_CALL_OPEN,
diff --git a/android/app/src/main/java/com/meshchatx/CallNotificationTrampolineActivity.java b/android/app/src/main/java/com/meshchatx/CallNotificationTrampolineActivity.java
new file mode 100644
index 00000000..25270b2e
--- /dev/null
+++ b/android/app/src/main/java/com/meshchatx/CallNotificationTrampolineActivity.java
@@ -0,0 +1,38 @@
+package com.meshchatx;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+
+/**
+ * Non-exported trampoline for incoming-call notification actions.
+ *
+ * <p>PendingIntents for answer/decline must not target the exported {@link MainActivity}.
+ * Same-profile apps can forge intents to exported activities. This component stays
+ * package-private so only our notification PendingIntents can start it.
+ */
+public final class CallNotificationTrampolineActivity extends Activity {
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ Intent inbound = getIntent();
+ String action = inbound != null ? inbound.getAction() : null;
+ String trusted = AndroidNotificationBridge.mapCallNotificationAction(action);
+ if (trusted != null) {
+ AndroidNotificationBridge.offerTrustedCallAction(trusted);
+ }
+
+ Intent main = new Intent(this, MainActivity.class);
+ main.addFlags(
+ Intent.FLAG_ACTIVITY_NEW_TASK
+ | Intent.FLAG_ACTIVITY_CLEAR_TOP
+ | Intent.FLAG_ACTIVITY_SINGLE_TOP
+ );
+ try {
+ startActivity(main);
+ } catch (Exception ignored) {
+ }
+ finish();
+ }
+}
diff --git a/android/app/src/main/java/com/meshchatx/MainActivity.java b/android/app/src/main/java/com/meshchatx/MainActivity.java
index 721792ed..84b5e8f0 100644
--- a/android/app/src/main/java/com/meshchatx/MainActivity.java
+++ b/android/app/src/main/java/com/meshchatx/MainActivity.java
@@ -767,16 +767,11 @@ public class MainActivity extends AppCompatActivity {
}
private void consumeCallIntentForPending(Intent intent) {
- if (intent == null) {
- return;
- }
- String a = intent.getAction();
- if (AndroidNotificationBridge.ACTION_CALL_ANSWER.equals(a)) {
- pendingCallNotificationAction = "answer";
- } else if (AndroidNotificationBridge.ACTION_CALL_DECLINE.equals(a)) {
- pendingCallNotificationAction = "decline";
- } else if (AndroidNotificationBridge.ACTION_CALL_OPEN.equals(a)) {
- pendingCallNotificationAction = "open";
+ // Answer / decline / open are queued only by CallNotificationTrampolineActivity
+ // (exported=false). Ignore CALL_ANSWER / CALL_DECLINE on this exported activity.
+ String trusted = AndroidNotificationBridge.takeTrustedCallAction();
+ if (trusted != null) {
+ pendingCallNotificationAction = trusted;
}
}
diff --git a/android/app/src/test/java/com/meshchatx/CallNotificationTrustedActionTest.java b/android/app/src/test/java/com/meshchatx/CallNotificationTrustedActionTest.java
new file mode 100644
index 00000000..4eebbd1d
--- /dev/null
+++ b/android/app/src/test/java/com/meshchatx/CallNotificationTrustedActionTest.java
@@ -0,0 +1,77 @@
+package com.meshchatx;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Trusted call-notification actions must not be forgeable via exported MainActivity.
+ */
+public class CallNotificationTrustedActionTest {
+
+ @After
+ public void tearDown() {
+ AndroidNotificationBridge.clearTrustedCallActionForTests();
+ }
+
+ @Test
+ public void mapCallNotificationActionRecognizesAnswerDeclineOpen() {
+ Assert.assertEquals(
+ AndroidNotificationBridge.TRUSTED_CALL_ACTION_ANSWER,
+ AndroidNotificationBridge.mapCallNotificationAction(
+ AndroidNotificationBridge.ACTION_CALL_ANSWER
+ )
+ );
+ Assert.assertEquals(
+ AndroidNotificationBridge.TRUSTED_CALL_ACTION_DECLINE,
+ AndroidNotificationBridge.mapCallNotificationAction(
+ AndroidNotificationBridge.ACTION_CALL_DECLINE
+ )
+ );
+ Assert.assertEquals(
+ AndroidNotificationBridge.TRUSTED_CALL_ACTION_OPEN,
+ AndroidNotificationBridge.mapCallNotificationAction(
+ AndroidNotificationBridge.ACTION_CALL_OPEN
+ )
+ );
+ Assert.assertNull(AndroidNotificationBridge.mapCallNotificationAction(null));
+ Assert.assertNull(AndroidNotificationBridge.mapCallNotificationAction("android.intent.action.VIEW"));
+ Assert.assertNull(
+ AndroidNotificationBridge.mapCallNotificationAction("com.meshchatx.action.CALL_ATTACK")
+ );
+ }
+
+ @Test
+ public void offerAndTakeTrustedCallActionIsOneShot() {
+ AndroidNotificationBridge.offerTrustedCallAction(
+ AndroidNotificationBridge.TRUSTED_CALL_ACTION_ANSWER
+ );
+ Assert.assertEquals(
+ AndroidNotificationBridge.TRUSTED_CALL_ACTION_ANSWER,
+ AndroidNotificationBridge.takeTrustedCallAction()
+ );
+ Assert.assertNull(AndroidNotificationBridge.takeTrustedCallAction());
+ }
+
+ @Test
+ public void offerTrustedCallActionRejectsUnknownTokens() {
+ AndroidNotificationBridge.offerTrustedCallAction("initiate");
+ AndroidNotificationBridge.offerTrustedCallAction("");
+ AndroidNotificationBridge.offerTrustedCallAction(null);
+ Assert.assertNull(AndroidNotificationBridge.takeTrustedCallAction());
+ }
+
+ @Test
+ public void latestTrustedCallActionWins() {
+ AndroidNotificationBridge.offerTrustedCallAction(
+ AndroidNotificationBridge.TRUSTED_CALL_ACTION_ANSWER
+ );
+ AndroidNotificationBridge.offerTrustedCallAction(
+ AndroidNotificationBridge.TRUSTED_CALL_ACTION_DECLINE
+ );
+ Assert.assertEquals(
+ AndroidNotificationBridge.TRUSTED_CALL_ACTION_DECLINE,
+ AndroidNotificationBridge.takeTrustedCallAction()
+ );
+ }
+}
diff --git a/meshchatx.rsm b/meshchatx.rsm
index 4ffe5c6f..1a418ad4 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/src/frontend/js/networkVisualiserWebGL.js b/meshchatx/src/frontend/js/networkVisualiserWebGL.js
index da2ee5d8..bad255be 100644
--- a/meshchatx/src/frontend/js/networkVisualiserWebGL.js
+++ b/meshchatx/src/frontend/js/networkVisualiserWebGL.js
@@ -286,7 +286,7 @@ export function prepareVisualiserIconPixels(data, mode = "opaque") {
data[i + 3] = alpha;
if (alpha >= 24) glyphPixels += 1;
}
- } else if (a === 0 && (r | g | b)) {
+ } else if (a === 0 && (r !== 0 || g !== 0 || b !== 0)) {
// Some RGB PNGs store color with a=0. Promote those only.
// Never crush existing soft alpha (RNS logo fringe looked jagged).
data[i + 3] = 255;
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────